from docx import Document
from docx.oxml.ns import nsdecls
from docx.oxml import parse_xml
from docx.text.paragraph import Paragraph
from docx.shared import Pt,  Inches, Cm,RGBColor
from docx.oxml.shared import OxmlElement,qn
from docx.enum.dml import MSO_THEME_COLOR
from docx.enum.table import WD_TABLE_ALIGNMENT #.CENTER，表格整体居中
from docx.enum.table import WD_ALIGN_VERTICAL #.TOP，.CENTER，.BOTTOM
from docx.enum.text import WD_ALIGN_PARAGRAPH #别名WD_PARAGRAPH_ALIGNMENT，后面要加点号：LEFT—左对齐，CENTER—居中对齐，RIGHT—右对齐，JUSTIFY—两端对齐
from docx.enum.text import WD_COLOR #别名WD_COLOR_INDEX，WD_COLOR.YELLOW
from docx.enum.text import WD_LINE_SPACING #后面要加点号：ONE_POINT_FIVE—1.5倍行距，AT_LEAST—最小值，DOUBLE—2倍行距，SINGLE—单倍行距
from docx.enum.style import WD_STYLE_TYPE #.PARAGRAPH，.CHARACTER， .TABLE， .LIST ，依次是1、2、3、4
from docx.enum.table import WD_ROW_HEIGHT_RULE #AUTO 调整行高以适应行中的最高值。AT_LEAST 行高至少是指定的最小值。EXACTLY 行高是一个精确的值。
from lxml import etree
import numpy as np
from pandas import DataFrame,Timestamp
import os, re, io,inspect,datetime,abc,time
from functools import partial
import matplotlib as mpl
import matplotlib.pyplot as plt


def fdir(f):
    return os.path.dirname(os.path.abspath(f))
def fname(f):
    return os.path.basename(os.path.abspath(f))  # 返回文件名
def fbase(f):
    return os.path.splitext(fname(f))[0]
def fext(f):
    return os.path.splitext(fname(f))[1][1:]

def callermod():
    s=inspect.stack()[2]
    return inspect.getmodule(s[0])

RE_TABLE=r'`\s*table\s*`'
RE_ENDTABLE=r'`\s*endtable\s*`'
RE_FIGURE=r'`\s*figure\s*`'
RE_ENDFIGURE=r'`\s*endfigure\s*`'
RE_IF=r'`\s*if ((?:(?!if).)*)\s*`'
RE_ELSE=r'`\s*else\s*`'
RE_ENDIF=r'`\s*endif\s*`'
#最初的规则是r'`\s*\w+\(?.*?\)?\s*`'，要求`xx`只能是变量或调用，不能是任意表达式，如`a>b`或`ab cd`或`3 if a>0 else 8`，这种要先在py内算好再做变量引用。但后来放宽了，随便都行，只要求值成功即可。
RE_INLINE_QUOTE=r'`.*?`'

class CTX():pass #█为了ctx.s赋立得，直接的object无法赋立得。
class TableInfo():pass #记录信息而已，不用字典是为了点取。
class FigureInfo():pass #记录信息而已

def render(docxfile, docxfileresult):
    if fext(docxfile)!='docx':
        oops('只支持docx格式')
    doc=Document(docxfile)
    newParaStyle(doc,'txx_fxx',size=9,align=1)
    #newParaStyle(doc,'cell_text',size=10) #没用到
    ctx=CTX() #█ctx只是为了在每个子状态的onXX代码中看到全局状态并变更。如果要给每个子状态传更多参数，就更适合用ctx打包了。
    ctx.doc=doc
    ctx.mod=callermod()
    ctx.s=InitState()
    ctx.tables=[] #保存搜集的TableInfo对象，出现新表时ctx.table=TableInfo()。表xx的全局编号从下标来。
    ctx.figures=[] #搜集的FigureInfo信息
    #下面2个字段共同决定当前状态。在if中，也可能是neg分支，如if False。默认图、表的标签是在全局的，如果是在if的激活分支内嵌套了图、表的开始标记，则加上这个标志，以便后续碰到图、表的结束标记时，状态机回到InPosBranch而非InitState。
    ctx.in_if_branch=False #True表示在if body，False表示在else body。按说应是3态，用None表示都不在。
    ctx.in_pos_branch=False
    _render(ctx)
    # fout=fdir(docxfile)+'/'+fbase(docxfile)+'_输出.docx'
    # fout = docxfileresult
    doc.save(docxfileresult)


def _render(ctx):
    paras=ctx.doc.paragraphs #█这里要引用doc对象，所以干脆把doc也加入了ctx，就不用传参了。
    for i in range(len(paras)):
        ctx.p=paras[i]
        txt=paras[i].text
        if re.search(RE_TABLE,txt):
            evt='table'
        elif re.search(RE_ENDTABLE,txt):
            evt='endtable'
        elif re.search(RE_FIGURE,txt):
            evt='figure'
        elif re.search(RE_ENDFIGURE,txt):
            evt='endfigure'
        elif re.search(RE_IF,txt):
            evt='if_'
        elif re.search(RE_ELSE,txt):
            evt='else_'
        elif re.search(RE_ENDIF,txt):
            evt='endif'
        elif re.search(RE_INLINE_QUOTE,txt): #█这个优先级倒数第2，否则最高的话，`table`会被当成表达式去求值。只要段内有一个满足RE_INLINE_QUOTE，就拿去求值。
            evt='inlineQuote'
        else: #如果没有`，或只有一个`，此段就算普通字符，优先级最低。
            evt='plain'
        getattr(ctx.s,evt)(ctx) #找到fsm对应方法，调用之。
    #去掉必杀标志
    [n.getparent().remove(n) for n in ctx.doc._body._element.xpath('w:p/w:r[w:t="█必杀█"]/..')]
    renderTable(ctx)
    renderFigure(ctx)

def renderTable(ctx):
    Tidx=0
    for p in ctx.doc.paragraphs:
        if p.text=="█这里插表█":
            p.text=f'表{Tidx+1} {ctx.tables[Tidx].name}'
            p.style='txx_fxx' #█此样式自带居中，但居然无效。所以需要下句再显式居中。
            p.alignment=1
            tinfo=ctx.tables[Tidx]
            try:
                data=eval(tinfo.data,ctx.mod.__dict__)
            except:
                oops(f'表{Tidx+1}的data不存在')
            if not isinstance(data,DataFrame):
                oops(f'表{Tidx+1}的data必须是pandas的DataFrame类型')
            cols=data.columns
            nrows=len(data)
            ncols=len(cols)
            if hasattr(tinfo,'bgColor'):
                for i in tinfo.bgColor.keys():
                    if i not in cols:
                        oops(f'表{Tidx+1}的bgColor规则中指定的表头{i}不存在')
            if hasattr(tinfo,'columnMap'):
                for i in tinfo.columnMap.keys():
                    if i not in cols:
                        oops(f'表{Tidx+1}的columnMap规则中指定的表头{i}不存在')
            t=ctx.doc.add_table(1, ncols) #先造表头。不能指定style，因为不一定存在，靠后面自己加。
            #t.autofit=False
            p._p.addnext(t._tbl)
            t.alignment = WD_TABLE_ALIGNMENT.CENTER
            setBorder(t,
                top=   {"sz": 2, "val": "double", "color": "#78C0D4"},
                bottom={"sz": 2, "val": "single",  "color": "#78C0D4" },
                left=  {"sz": 2, "val": "single", "color": "#78C0D4"},
                right= {"sz": 2, "val": "single", "color": "#78C0D4"},
                insideH= {"sz": 2, "val": "single", "color": "#78C0D4"},
                insideV= {"sz": 2, "val": "single", "color": "#78C0D4"},
                )
            #表头美化
            for i,c in enumerate(t.row_cells(0)):
                setCellBg(c,'#D2EAF0')
                pp=c.paragraphs[0] #默认是有一段的，但仅有一段。即使自己没加过。
                dispName=colName=cols[i]
                if hasattr(tinfo,'columnMap') and colName in tinfo.columnMap:
                    mapinfo=tinfo.columnMap[colName]
                    dispName=mapinfo['dispName'] #更新为表头的友好名
                    #从表头开始，一列的每个单元都设列宽。但效果好像还是不精确。wps会自动调整列宽，最终呈现的宽度可能没有那么精确，跟指定的有误差。高度无需设置，自动的就ok
                    if mapinfo['width']:
                        t.columns[i].width=Cm(mapinfo['width'])
                        #c.width=Cm(mapinfo['width'])
                pp.text=dispName
                pp.style='txx_fxx'
                c.vertical_alignment = WD_ALIGN_VERTICAL.CENTER
            if hasattr(tinfo,'maxRows'):
                nrows=min(nrows, tinfo.maxRows)
            #添加每行数据
            for ridx in range(nrows):
                #███ 注意此行，导致后续所有的cdata都是np类型，不是简单的bool或浮点。
                rdata=data.iloc[ridx]
                for i,c in enumerate(t.add_row().cells):
                    cdata=rdata[i]
                    colName=cols[i]
                    pp=c.paragraphs[0]
                    pp.text=str(cdata)
                    #默认保留2位
                    if isinstance(cdata,(np.float64, np.float32, np.float16)):
                        pp.text=format(cdata, '.2f')
                    pp.style='txx_fxx'
                    #pp.alignment=3 #改回默认的居左
                    c.vertical_alignment = WD_ALIGN_VERTICAL.CENTER
                    if ridx%2==1: #奇数行，加默认底色
                        setCellBg(c,'#ffffff')
                    else:
                        setCellBg(c,'#F0F0F4')
                    #列宽和小数点处理
                    if hasattr(tinfo,'columnMap') and colName in tinfo.columnMap:
                        mapinfo=tinfo.columnMap[colName]
                        #表头列设置宽度后，剩余列无需设置。
                        #if mapinfo['width']: #key一直都在，用值为None表示不存在。
                            #t.columns[i].width=Cm(mapinfo['width'])
                            #c.width=Cm(mapinfo['width'])
                        if mapinfo['round']is not None and isinstance(cdata,(np.float16, np.float32, np.float64)):
                            pp.text=format(cdata, f'.{mapinfo["round"]}f')
                    #每个单元的颜色渲染
                    if hasattr(tinfo,'bgColor') and colName in tinfo.bgColor:
                        for rule,color in tinfo.bgColor[colName]: #形如[(rule,color)...]
                            res= eval(rule,{colName:cdata})
                            #█对形如qty>100的表达式求值，赋予{qty:np.float64}，eval结果是<class 'numpy.bool_'>型，不等于bool！
                            #print(rule,color,res,type(res),type(res)==bool)
                            #if not type(res)==bool: 结果永远是真，不能这样写
                            if not isinstance(res,np.bool_):
                                oops(f'表{Tidx+1}的{colName}列的渲染规则{rule}语法有误，求值结果要是bool型')
                            if res:
                                setCellBg(c,color)
            Tidx+=1


#在renderFigure函数内把人性化的配置信息转化得到的字典再转化为参数列表，传给superDraw()函数，里面按参数画所有常见图形。
def renderFigure(ctx):
    Fidx=0
    s=ctx.doc.sections[0]
    net_page_width=s.page_width.cm-s.left_margin.cm-s.right_margin.cm
    for p in ctx.doc.paragraphs:
        if p.text=="█这里插图█":
            p.text=f'图{Fidx+1} {ctx.figures[Fidx].name}'
            p.style='txx_fxx' #█此样式自带居中，但居然无效。所以需要下句再显式居中。
            p.alignment=1
            finfo=ctx.figures[Fidx]
            yspec=[]
            try:
                data=eval(finfo.data,ctx.mod.__dict__)
            except:
                oops(f'图{Fidx+1}的data属性对应的表不存在')
            if not isinstance(data,DataFrame):
                oops(f'图{Fidx+1}的data属性必须是pandas的DataFrame类型')
            realColumns=data.columns
            if hasattr(finfo,'xaxisData'):
                if finfo.xaxisData not in realColumns:
                    oops(f'图{Fidx+1}的横轴数据属性指定的表头{finfo.xaxisData}不存在')
                xdata=eval(f'{finfo.data}.{finfo.xaxisData}',ctx.mod.__dict__)
            if hasattr(finfo,'bar'):
                mode=finfo.bar['mode']
                cols=finfo.bar['cols']
                if mode=='multi':
                    how=finfo.bar['how']
                else:
                    how=''
                dataList,labelList=[],[]
                for col in cols:  #形如 华中/华中区，或仅 华南，表示表头名和对应图例中的标签名
                    tmp=[i.strip() for i in col.split('/')]
                    if len(tmp)>2:
                        oops(f'表{Fidx+1}的柱子属性指定表头时不能使用超过2个斜杠')
                    if len(tmp)==1:
                        head=tmp[0]
                        lbl=head
                    else:
                        head=tmp[0]
                        lbl=tmp[1]
                    if head not in realColumns:
                        oops(f'表{Fidx+1}的柱子属性指定的表头{head}不存在')
                    coldata=eval(f'{finfo.data}.{head}',ctx.mod.__dict__)
                    dataList.append(coldata)
                    labelList.append(lbl)
                yspec.append(('bar','主坐标',dataList,labelList,how))
            if hasattr(finfo,'line'):
                cols=finfo.line
                dataList,labelList=[],[]
                for col in cols:  #可能是华中/华中区，表示表头名和对应图例中的标签名。或仅有华南。
                    tmp=[i.strip() for i in col.split('/')]
                    if len(tmp)>2:
                        oops(f'表{Fidx+1}的折线属性指定表头时不能使用超过2个斜杠')
                    if len(tmp)==1:
                        head=tmp[0]
                        lbl=head
                    else:
                        head=tmp[0]
                        lbl=tmp[1]
                    if head not in realColumns:
                        oops(f'表{Fidx+1}的折线属性指定的表头{head}不存在')
                    coldata=eval(f'{finfo.data}.{head}',ctx.mod.__dict__)
                    dataList.append(coldata)
                    labelList.append(lbl)
                yspec.append(('line','主坐标',dataList,labelList))
            if hasattr(finfo,'hline'):
                dataList=[]
                #输入可能是30;2021-9-8;abc这种，但只接受数字。
                for data in finfo.hline:
                    try:
                        d=float(data)
                    except:
                        oops(f'表{Fidx+1}的水平线属性指定的值必须都是数字')
                    dataList.append(d)
                yspec.append(('hline','主坐标',dataList))
            if hasattr(finfo,'vline'):
                dataList=[]
                #输入只能是30;20210808_08;20210808这种。异常日期格式和str都不接受。虽然用axvline(str)是合法的。
                for data in finfo.vline:
                    #若转时间失败则转浮点
                    try:
                        d=datetime.datetime(*time.strptime(data,'%Y%m%d_%H')[:6])
                    except:
                        try:
                            d=datetime.datetime(*time.strptime(data,'%Y%m%d')[:6])
                        except:
                            try:
                                d=float(data)
                            except:
                                oops(f'表{Fidx+1}的垂直线属性指定的值必须是数字，或日期的天/小时')
                    dataList.append(d)
                if len(set([type(i) for i in dataList]))>1:
                    oops(f'表{Fidx+1}的垂直线属性指定的值必须都为日期或都为数字，不能混用')
                yspec.append(('vline','主坐标',dataList))
            if hasattr(finfo,'bar2'):
                mode=finfo.bar2['mode']
                cols=finfo.bar2['cols']
                if mode=='multi':
                    how=finfo.bar2['how']
                else:
                    how=''
                dataList,labelList=[],[]
                for col in cols:  #可能是华中/华中区，表示表头名和对应图例中的标签名。或仅有华南。
                    tmp=[i.strip() for i in col.split('/')]
                    if len(tmp)>2:
                        oops(f'表{Fidx+1}的副坐标柱子属性指定表头时不能使用超过2个斜杠')
                    if len(tmp)==1:
                        head=tmp[0]
                        lbl=head
                    else:
                        head=tmp[0]
                        lbl=tmp[1]
                    if head not in realColumns:
                        oops(f'表{Fidx+1}的副坐标柱子属性指定的表头{head}不存在')
                    coldata=eval(f'{finfo.data}.{head}',ctx.mod.__dict__)
                    dataList.append(coldata)
                    labelList.append(lbl)
                yspec.append(('bar','副坐标',dataList,labelList,how))
            if hasattr(finfo,'line2'):
                cols=finfo.line2
                dataList,labelList=[],[]
                for col in cols:  #可能是华中/华中区，表示表头名和对应图例中的标签名。或仅有华南。
                    tmp=[i.strip() for i in col.split('/')]
                    if len(tmp)>2:
                        oops(f'表{Fidx+1}的副坐标折线属性指定表头时不能使用超过2个斜杠')
                    if len(tmp)==1:
                        head=tmp[0]
                        lbl=head
                    else:
                        head=tmp[0]
                        lbl=tmp[1]
                    if head not in realColumns:
                        oops(f'表{Fidx+1}的副坐标折线属性指定的表头{head}不存在')
                    coldata=eval(f'{finfo.data}.{head}',ctx.mod.__dict__)
                    dataList.append(coldata)
                    labelList.append(lbl)
                yspec.append(('line','副坐标',dataList,labelList))
            if hasattr(finfo,'hline2'):
                dataList=[]
                #输入可能是30;2021-9-8;abc这种，但只接受数字。
                for data in finfo.hline2:
                    try:
                        d=float(data)
                    except:
                        oops(f'表{Fidx+1}的副坐标水平线属性指定的值必须都是数字')
                    dataList.append(d)
                yspec.append(('hline','副坐标',dataList))
            if hasattr(finfo,'pie'):
                d,l=finfo.pie
                if d not in realColumns:
                    oops(f'表{Fidx+1}的大饼属性指定的数据列{d}不存在')
                if l not in realColumns:
                    oops(f'表{Fidx+1}的大饼属性指定的标签列{l}不存在')
                data=eval(f'{finfo.data}.{d}',ctx.mod.__dict__)
                label=eval(f'{finfo.data}.{l}',ctx.mod.__dict__)
                try:
                    list(map(float,data))
                except:
                    oops(f'表{Fidx+1}的大饼属性指定的数据列{d}不是数字')
                xdata=data
                yspec.append(('pie',label))
            xaxisLabel=getattr(finfo,'xaxisLabel',None)
            yaxisLabel=getattr(finfo,'yaxisLabel',None)
            yaxisLabel2=getattr(finfo,'yaxisLabel2',None)
            flike=superDraw(xdata,yspec,xaxisLabel,yaxisLabel,yaxisLabel2)
            #print(xdata,yspec,xaxisLabel,yaxisLabel,yaxisLabel2)
            np=p.insert_paragraph_before()
            np.alignment=1
            np.add_run().add_picture(flike,width=Cm(net_page_width))
            flike.close()
            Fidx+=1


class State(metaclass=abc.ABCMeta):
    @abc.abstractmethod
    def plain(self,ctx):  pass
    @abc.abstractmethod
    def table(self,ctx):  pass
    @abc.abstractmethod
    def endtable(self,ctx):  pass
    @abc.abstractmethod
    def figure(self,ctx):  pass
    @abc.abstractmethod
    def endfigure(self,ctx):  pass
    @abc.abstractmethod
    def inlineQuote(self,ctx):  pass
    @abc.abstractmethod
    def if_(self,ctx):  pass
    @abc.abstractmethod
    def else_(self,ctx):  pass
    @abc.abstractmethod
    def endif(self,ctx):  pass

class InitState(State):
    def plain(self,ctx):
        pass
    def table(self,ctx):
        ctx.p.text='█必杀█' #做标记，后续删除本段落，避免在迭代过程中做删除。
        ctx.s=InTable() #█其实不必每次都新建fsm对象，搞几个全局的即可，反正没有字段只要方法。
        ctx.table=TableInfo()
    def endtable(self,ctx): #都抛异常了，就无需设置  █必杀█  标记了。
        oops(f'请检查{ctx.p.text}，语法有误，没有前置table')
    def figure(self,ctx):
        ctx.p.text='█必杀█'
        ctx.s=InFigure()
        ctx.figure=FigureInfo()
    def endfigure(self,ctx):
        oops(f'请检查{ctx.p.text}，语法有误，没有前置figure')
    def inlineQuote(self,ctx):
        cb=partial(evalQuote,ctx.mod)
        try:
            replacedStr=re.sub(RE_INLINE_QUOTE, cb,  ctx.p.text)
            setParaText(ctx.p,replacedStr)
        except:
            oops(f'请检查{ctx.p.text}，变量或表达式有错误，求值失败')
    def if_(self,ctx):
        expr=re.findall(RE_IF, ctx.p.text)[0]
        try:
            res=eval(expr,ctx.mod.__dict__)
            #█if表达式的结果可能是两种bool！
            if not isinstance(res,(bool,np.bool_)):
                oops(f'if表达式的求值结果要是bool型')
            ctx.in_if_branch=True
            if res:
                ctx.in_pos_branch=True
                ctx.s=InPosBranch()
            else:
                ctx.s=InNegBranch()
            ctx.p.text='█必杀█'
        except:
            oops(f'请检查{ctx.p.text}，if中的变量或表达式有错误')
    def else_(self,ctx):
        oops(f'请检查{ctx.p.text}，语法有误，没有前置if')
    def endif(self,ctx):
        oops(f'请检查{ctx.p.text}，语法有误，没有前置if')

#██ 图表都有3处可以插入规则合法性校验的地方。收到每条规则时；收到endxx时；开始渲染图或表时。
#存在性、唯一性、互斥性（AB不能共存）、类型、长度、值（未定义，超值域，fsm非法输入，异常嵌套）。
class InTable(State):
    def plain(self,ctx):
        t=ctx.p.text
        if t=='':
            ctx.p.text='█必杀█'
            return
        #兼容中文
        t=t.replace('：',':')
        t=t.replace('；',';')
        N=t.count('=')
        if N==0:
            oops(f'请检查{ctx.p.text}，语法有误，需要a=b这样的设置项')
        if N>1:
            oops(f'请检查{ctx.p.text}，语法有误，设置项中仅能出现1个等号')
        L,R=t.split('=')
        L,R=L.strip(),R.strip()
        if L=='' or R=='':
            oops(f'请检查{ctx.p.text}，语法有误，a=b这样的设置项等号两边都不能为空')
        if L=='name':
            if hasattr(ctx.table,'name'):
                oops(f'请检查{ctx.p.text}，语法有误，表格的name属性重复设置')
            ctx.table.name=R
        elif L=='data':
            if hasattr(ctx.table,'data'):
                oops(f'请检查{ctx.p.text}，语法有误，表格的data属性重复设置')
            ctx.table.data=R
        elif L=='maxRows':
            try:
                R=int(R)
            except:
                oops(f'请检查{ctx.p.text}，语法有误，表格的maxRows属性要是整数')
            if hasattr(ctx.table,'maxRows'):
                oops(f'请检查{ctx.p.text}，语法有误，表格的maxRows属性重复设置')
            ctx.table.maxRows=R
        elif L=='bgColor': #允许多条此规则
            N=R.count(':')
            if N==0:
                oops(f'请检查{ctx.p.text}，语法有误，表格着色规则中等号右侧需要a:b这样的设置项')
            if N>1:
                oops(f'请检查{ctx.p.text}，语法有误，表格着色规则中等号右侧只能有1个冒号')
            rule,color=R.split(':')
            rule,color=rule.strip(), color.strip()
            if rule=='' or color=='':
                oops(f'请检查{ctx.p.text}，语法有误，a:b这样的设置项冒号两边都不能为空')
            renderTarget=coloredColumn(rule)
            if len(renderTarget)==0:
                oops(f'请检查{ctx.p.text}，语法有误，表格着色规则中没有指定列变量')
            if len(renderTarget)> 1:
                oops(f'请检查{ctx.p.text}，语法有误，表格着色规则中列变量多于一个，不支持类似 a>sin(8) 或 a>b 这样的写法，会解析为两个列变量')
            renderTarget=renderTarget[0]
            if not re.match(r'#[0-9a-fA-F]{6}',color):
                oops(f'请检查{ctx.p.text}，语法有误，表格着色规则中颜色格式为#xxyyzz')
            if not hasattr(ctx.table,'bgColor'):
                ctx.table.bgColor={}
            if renderTarget not in ctx.table.bgColor:
                ctx.table.bgColor[renderTarget]=[(rule,color)]
            else:
                ctx.table.bgColor[renderTarget].append((rule,color))
        elif L=='columnMap': #允许多条此规则
            #█允许 foo_bar : foo bar映射后的新表头带空格，甚至映射到数字。
            N=R.count(':')
            if N==0:
                oops(f'请检查{ctx.p.text}，语法有误，表头映射规则中等号右侧需要a:b;c这样的设置项')
            if N>1:
                oops(f'请检查{ctx.p.text}，语法有误，表头映射规则中等号右侧只能有1个冒号')
            colName,colSpec=R.split(':')
            colName,colSpec=colName.strip(), colSpec.strip()
            if colName=='' or colSpec=='':
                oops(f'请检查{ctx.p.text}，语法有误，a:b;c这样的设置项冒号两边都不能为空')
            width, round=None,None
            if ';' not in colSpec: #columnMap=old:new模式
                dispName=colSpec
            else:
                #columnMap=old:new; 或 old:new;xx 或old:new;xx; 或old:new;xx;yy，看冒号右侧部分的可能形态
                #只有分号左侧的内容为必选，右侧都是可选，可继续包含0~N个分号。所以只切一次。
                p1,p2=colSpec.split(';',1)
                p1,p2=p1.strip(),p2.strip()
                if p1=='':
                    oops(f'请检查{ctx.p.text}，语法有误，第一个分号左边的新表头名不能为空')
                dispName=p1
                #█右侧可选部分就不再按切割方式分析，按re就方便多了，比如不关心xx;yy还是xx;yy;还是xx;;yy;;，容错性好。
                #██ 如果写的厘米数不是整数或浮点数，或写的小数位数不是整数，都会静默忽略。
                w=re.findall(r'宽\s*(\d+\.\d+|[1-9]\d*)\s*cm',p2)
                if w:
                    width=float(w[0])
                r=re.findall(r'保留\s*(\d+)\s*位小数',p2)
                if r:
                    round=int(r[0])
            colinfo=dict(dispName=dispName,width=width, round=round)
            if not hasattr(ctx.table,'columnMap'):
                ctx.table.columnMap={}
            ctx.table.columnMap[colName]=colinfo
        else:
            oops(f'请检查{ctx.p.text}，语法有误，表格的属性{L}不支持')
        ctx.p.text='█必杀█'
    def table(self,ctx):
        oops(f'请检查{ctx.p.text}，语法有误，表格定义内不能再出现`table`')
    def endtable(self,ctx):
        if not hasattr(ctx.table,'name'):
            oops(f'表格定义语法有误，必选的name属性未定义')
        if not hasattr(ctx.table,'data'):
            oops(f'表格定义语法有误，必选的data属性未定义')
        #██ 其他检查放到renderTable时再做，比如规则所列表头的存在性。
        ctx.p.text='█这里插表█'
        ctx.s=InitState() if not ctx.in_pos_branch else InPosBranch()
        ctx.tables.append(ctx.table)
    def figure(self,ctx):
        oops(f'请检查{ctx.p.text}，语法有误，表格定义内不能出现`figure`')
    def endfigure(self,ctx):
        oops(f'请检查{ctx.p.text}，语法有误，表格定义内不能出现`endfigure`')
    def inlineQuote(self,ctx):
        oops(f'请检查{ctx.p.text}，语法有误，表格定义内不能出现`表达式`')
    def if_(self,ctx):
        oops(f'请检查{ctx.p.text}，语法有误，表格定义内不能出现`if 表达式`')
    def else_(self,ctx):
        oops(f'请检查{ctx.p.text}，语法有误，表格定义内不能出现`else`')
    def endif(self,ctx):
        oops(f'请检查{ctx.p.text}，语法有误，表格定义内不能出现`endif`')

class InFigure(State):
    def plain(self,ctx):
        t=ctx.p.text
        if t=='':
            ctx.p.text='█必杀█'
            return
        #兼容中文
        t=t.replace('：',':')
        t=t.replace('；',';')
        N=t.count('=')
        if N==0:
            oops(f'请检查{ctx.p.text}，语法有误，需要a=b这样的设置项')
        if N>1:
            oops(f'请检查{ctx.p.text}，语法有误，设置项中仅能出现1个等号')
        L,R=t.split('=')
        L,R=L.strip(),R.strip()
        if L=='' or R=='':
            oops(f'请检查{ctx.p.text}，语法有误，a=b这样的设置项等号两边都不能为空')
        if L=='name':
            if hasattr(ctx.figure,'name'):
                oops(f'请检查{ctx.p.text}，语法有误，图片的name属性重复设置')
            ctx.figure.name=R
        elif L=='data':
            if hasattr(ctx.figure,'data'):
                oops(f'请检查{ctx.p.text}，语法有误，图片的data属性重复设置')
            ctx.figure.data=R
        elif L=='横轴数据':
            if hasattr(ctx.figure,'xaxisData'):
                oops(f'请检查{ctx.p.text}，语法有误，图片的横轴数据属性重复设置')
            ctx.figure.xaxisData=R
        elif L=='横轴标签':
            if hasattr(ctx.figure,'xaxisLabel'):
                oops(f'请检查{ctx.p.text}，语法有误，图片的横轴标签属性重复设置')
            ctx.figure.xaxisLabel=R
        elif L=='纵轴标签':
            if hasattr(ctx.figure,'yaxisLabel'):
                oops(f'请检查{ctx.p.text}，语法有误，图片的纵轴标签属性重复设置')
            ctx.figure.yaxisLabel=R
        elif L=='副坐标纵轴标签':
            if hasattr(ctx.figure,'yaxisLabel2'):
                oops(f'请检查{ctx.p.text}，语法有误，图片的副坐标纵轴标签属性重复设置')
            ctx.figure.yaxisLabel2=R
        #以下图形即使只有一个序列也用列表传递
        elif L=='柱子':
            if hasattr(ctx.figure,'bar'):
                oops(f'请检查{ctx.p.text}，语法有误，图片的柱子属性重复设置')
            bars=list(filter(lambda x:len(x)>0, [i.strip() for i in R.split(';')]))
            c1=bars.count('并排')
            c2=bars.count('堆叠')
            c3=len(bars)-c1-c2
            if c3==0:
                oops(f'请检查{ctx.p.text}，语法有误，柱子的数据列为空')
            if c1>1 or c2>1:
                oops(f'请检查{ctx.p.text}，语法有误，柱子并排或堆叠模式指示只能写1次')
            if c1==1 and c2==1:
                oops(f'请检查{ctx.p.text}，语法有误，柱子并排和堆叠模式指示只能二选一')
            if c1==0 and c2==0:
                if c3==1:
                    ctx.figure.bar={'mode':'single', 'cols':bars}
                else:
                    #多个柱子，不写模式，就默认并排模式。
                    ctx.figure.bar={'mode':'multi', 'cols':bars,'how':'并排'}
            else:
                if c3==1:
                    oops(f'请检查{ctx.p.text}，语法有误，单柱子时不能指定并排或堆叠模式')
                if c1==1:
                    bars.remove('并排')
                    how='并排'
                else:
                    bars.remove('堆叠')
                    how='堆叠'
                ctx.figure.bar={'mode':'multi', 'cols':bars,'how':how}
        elif L=='折线':
            if hasattr(ctx.figure,'line'):
                oops(f'请检查{ctx.p.text}，语法有误，图片的折线属性重复设置')
            lines=filter(lambda x:len(x)>0, [i.strip() for i in R.split(';')])
            if not lines:
                oops(f'请检查{ctx.p.text}，语法有误，折线的数据列为空')
            ctx.figure.line=lines
        elif L=='水平线':
            if hasattr(ctx.figure,'hline'):
                oops(f'请检查{ctx.p.text}，语法有误，图片的水平线属性重复设置')
            hlines=filter(lambda x:len(x)>0, [i.strip() for i in R.split(';')])
            if not hlines:
                oops(f'请检查{ctx.p.text}，语法有误，水平线的数据列为空')
            ctx.figure.hline=hlines
        elif L=='垂直线':
            if hasattr(ctx.figure,'vline'):
                oops(f'请检查{ctx.p.text}，语法有误，图片的垂直线属性重复设置')
            vlines=filter(lambda x:len(x)>0, [i.strip() for i in R.split(';')])
            if not vlines:
                oops(f'请检查{ctx.p.text}，语法有误，垂直线的数据列为空')
            ctx.figure.vline=vlines
        elif L=='副坐标柱子':
            if hasattr(ctx.figure,'bar2'):
                oops(f'请检查{ctx.p.text}，语法有误，图片的副坐标柱子属性重复设置')
            bars=list(filter(lambda x:len(x)>0, [i.strip() for i in R.split(';')]))
            c1=bars.count('并排')
            c2=bars.count('堆叠')
            c3=len(bars)-c1-c2
            if c3==0:
                oops(f'请检查{ctx.p.text}，语法有误，柱子的数据列为空')
            if c1>1 or c2>1:
                oops(f'请检查{ctx.p.text}，语法有误，柱子并排或堆叠模式指示只能写1次')
            if c1==1 and c2==1:
                oops(f'请检查{ctx.p.text}，语法有误，柱子并排和堆叠模式指示只能二选一')
            if c1==0 and c2==0:
                if c3==1:
                    ctx.figure.bar2={'mode':'single', 'cols':bars}
                else:
                    #多个柱子，不写模式，就默认并排模式。
                    ctx.figure.bar2={'mode':'multi', 'cols':bars,'how':'并排'}
            else:
                if c3==1:
                    oops(f'请检查{ctx.p.text}，语法有误，单柱子时不能指定并排或堆叠模式')
                if c1==1:
                    bars.remove('并排')
                    how='并排'
                else:
                    bars.remove('堆叠')
                    how='堆叠'
                ctx.figure.bar2={'mode':'multi', 'cols':bars,'how':how}
        elif L=='副坐标折线':
            if hasattr(ctx.figure,'line2'):
                oops(f'请检查{ctx.p.text}，语法有误，图片的副坐标折线属性重复设置')
            lines=filter(lambda x:len(x)>0, [i.strip() for i in R.split(';')])
            if not lines:
                oops(f'请检查{ctx.p.text}，语法有误，折线的数据列为空')
            ctx.figure.line2=lines
        elif L=='副坐标水平线':
            if hasattr(ctx.figure,'hline2'):
                oops(f'请检查{ctx.p.text}，语法有误，图片的副坐标水平线属性重复设置')
            hlines=filter(lambda x:len(x)>0, [i.strip() for i in R.split(';')])
            if not hlines:
                oops(f'请检查{ctx.p.text}，语法有误，水平线的数据列为空')
            ctx.figure.hline2=hlines
        #█没有副坐标垂直线！！
        elif L=='大饼':
            if hasattr(ctx.figure,'pie'):
                oops(f'请检查{ctx.p.text}，语法有误，图片的大饼属性重复设置')
            pcfg=list(filter(lambda x:len(x)>0, [i.strip() for i in R.split(';')]))
            c=len(pcfg)
            if c!=2:
                oops(f'请检查{ctx.p.text}，语法有误，大饼的配置必须为"数据列;标签列"')
            ctx.figure.pie=pcfg
        else:
            oops(f'请检查{ctx.p.text}，语法有误，图片的属性{L}不支持')
        ctx.p.text='█必杀█'
    def table(self,ctx):
        oops(f'请检查{ctx.p.text}，语法有误，图片定义内不能出现`table`')
    def endtable(self,ctx):
        oops(f'请检查{ctx.p.text}，语法有误，图片定义内不能出现`endtable`')
    def figure(self,ctx):
        oops(f'请检查{ctx.p.text}，语法有误，图片定义内不能再出现`figure`')
    def endfigure(self,ctx):
        if not hasattr(ctx.figure,'name'):
            oops(f'图片定义语法有误，必选的name属性未定义')
        if not hasattr(ctx.figure,'data'):
            oops(f'图片定义语法有误，必选的data属性未定义')
        if hasattr(ctx.figure,'pie'):
            if hasAnyAttr(ctx.figure,['xaxisData','xaxisLabel','yaxisLabel','yaxisLabel2','bar','line','hline','vline','bar2','line2','hline2']):
                oops(f'图片定义语法有误，大饼属性不能与其他图形属性共存')
        else:
            if not hasattr(ctx.figure,'xaxisData'):
                oops(f'图片定义语法有误，必选的横轴数据属性未定义')
            if not hasAnyAttr(ctx.figure,['bar','line','hline','vline','bar2','line2','hline2']):
                oops(f'图片定义语法有误，柱子、折线、水平线、垂直线、副坐标柱子、副坐标折线、副坐标水平线至少要定义一个')
        ctx.p.text='█这里插图█'
        ctx.s=InitState() if not ctx.in_pos_branch else InPosBranch()
        ctx.figures.append(ctx.figure)
    def inlineQuote(self,ctx):
        cb=partial(evalQuote,ctx.mod)
        try:
            replacedStr=re.sub(RE_INLINE_QUOTE, cb,  ctx.p.text)
            ctx.p.text=replacedStr
            #预解析后当作plain再传入fsm
            self.plain(ctx)
        except:
            oops(f'请检查{ctx.p.text}，变量或表达式有错误，求值失败')
    def if_(self,ctx):
        oops(f'请检查{ctx.p.text}，语法有误，图片定义内不能出现`if 表达式`')
    def else_(self,ctx):
        oops(f'请检查{ctx.p.text}，语法有误，图片定义内不能出现`else`')
    def endif(self,ctx):
        oops(f'请检查{ctx.p.text}，语法有误，图片定义内不能出现`endif`')
#███  if激活分支内可定义图、表，但不能再嵌套if。如果图、表、if的标签不闭合，会自然出现异常，也可能不异常。如
#figure后接name=foo，然后全文终，则由于没有看到endfigure，结果是此图的定义不完整，不会加入待渲染列表。
#图不会被渲染，且这几行被删除。又如if xx后接`age`，然后全文终，则结果是age被渲染出来，if xx被删掉。
class InPosBranch(State):
    def plain(self,ctx):
        pass
    def table(self,ctx):
        ctx.p.text='█必杀█' #做标记，后续删除本段落，避免在迭代过程中做删除。
        ctx.s=InTable() #█其实不必每次都新建fsm对象，搞几个全局的即可，反正没有字段只要方法。
        ctx.table=TableInfo()
    def endtable(self,ctx):
        oops(f'请检查{ctx.p.text}，语法有误，if激活分支内不能出现单独的`endtable`')
    def figure(self,ctx):
        ctx.p.text='█必杀█'
        ctx.s=InFigure()
        ctx.figure=FigureInfo()
    def endfigure(self,ctx):
        oops(f'请检查{ctx.p.text}，语法有误，if激活分支内不能出现单独的`endfigure`')
    def inlineQuote(self,ctx):
        cb=partial(evalQuote,ctx.mod)
        try:
            replacedStr=re.sub(RE_INLINE_QUOTE, cb,  ctx.p.text)
            setParaText(ctx.p,replacedStr)
        except:
            oops(f'请检查{ctx.p.text}，变量或表达式有错误，求值失败')
    def if_(self,ctx):
        oops(f'请检查{ctx.p.text}，语法有误，不支持嵌套`if 表达式`')
    def else_(self,ctx):
        if not ctx.in_if_branch:
            oops(f'请检查{ctx.p.text}，语法有误，else分支内不能再嵌套`else`')
        ctx.p.text='█必杀█'
        ctx.in_if_branch=False
        ctx.in_pos_branch=False
        ctx.s=InNegBranch()
    def endif(self,ctx):
        ctx.p.text='█必杀█'
        ctx.in_if_branch=False
        ctx.in_pos_branch=False
        ctx.s=InitState()

class InNegBranch(State):
    def plain(self,ctx):
        ctx.p.text='█必杀█'
    table=plain
    endtable=plain
    figure=plain
    endfigure=plain
    inlineQuote=plain
    def if_(self,ctx):
        oops(f'请检查{ctx.p.text}，语法有误，不支持嵌套`if 表达式`')
    def else_(self,ctx):
        if not ctx.in_if_branch:
            oops(f'请检查{ctx.p.text}，语法有误，else分支内不能再嵌套`else`')
        ctx.p.text='█必杀█'
        ctx.in_if_branch=False
        ctx.in_pos_branch=True
        ctx.s=InPosBranch()
    def endif(self,ctx):
        ctx.p.text='█必杀█'
        ctx.in_if_branch=False
        ctx.in_pos_branch=False
        ctx.s=InitState()


def evalQuote(mod,matched):
    expr = matched.group(0)[1:-1]
    #print(expr)
    res=eval(expr,mod.__dict__)
    if isinstance(res,(float, np.float64, np.float32, np.float16)):
       res=format(res, '.2f')
    return str(res)

def coloredColumn(s):
    s=re.sub(r'''(['"]).*?\1''','',s)
    s=re.sub(r'and|or|not','',s)
    s=re.sub(r'0[xXoObB]','',s)
    return list(set(re.findall(r'[a-zA-Z_]\w*',s))) #list去重后再回到list

def oops(msg):
    print(msg)
    exit(1)

def hasAnyAttr(obj,attrs):
    ret=False
    for i in attrs:
        ret=ret or hasattr(obj,i)
    return ret


#整体替换一段的文本，主要是要保持样式。当然，清空样式后再用代码设置也行。
def setParaText(para,text):
    para.text=text
    rPr=para._p.xpath('w:pPr/w:rPr') #█始终返回的是列表！！！！
    if rPr: #非空列表
        para.runs[0]._r.insert(0,rPr[0]) #移花接木，挪走了节点。

def newParaStyle(doc,sname,fontname='微软雅黑',size=10.5,align=3):
    #默认10.5是五号。Pt(9)是小五大小，图xx，表xx适用。3是默认左对齐，1是居中。
    s=doc.styles.add_style(sname,1)
    s.font.name=fontname
    rpr = s.element.get_or_add_rPr()
    rFonts = rpr.get_or_add_rFonts()
    rFonts.set(qn('w:eastAsia'),fontname)
    s.font.size=Pt(size)
    s.paragraph_format.alignment=align


def setBorder(t, **kwargs):
    #left/right是老的规范，新的规范是start/end，但wps不认后者。
    tblPr = t._tblPr
    # check for tag existnace, if none found, then create one
    border = tblPr.first_child_found_in("w:tblBorders")
    if border is None:
        border = OxmlElement('w:tblBorders')
        tblPr.append(border)
    # list over all available tags
    for edge in ('left', 'top', 'right', 'bottom', 'insideH', 'insideV'):
        edge_data = kwargs.get(edge)
        if edge_data:
            tag = 'w:{}'.format(edge)
            # check for tag existnace, if none found, then create one
            element = border.find(qn(tag))
            if element is None:
                element = OxmlElement(tag)
                border.append(element)
            # looks like order of attributes is important
            for key in ["sz", "val", "color", "space", "shadow"]:
                if key in edge_data:
                    element.set(qn('w:{}'.format(key)), str(edge_data[key]))

def setCellBg(cell,bgstr):
    #bgstr形如 '#ff00ff'
    #shading节点只能给一个cell用，如果别的cell也用的话，则底色会挪到别的单元。故每个单元都要创建自己的shading。
    #shd还有其他属性 w:val="clear" w:color="auto" w:themeFill="accent3"
    shading = parse_xml(f'<w:shd {nsdecls("w")} w:fill="{bgstr}" />')
    #网上版本如下。测试时出了诡异问题，当用t.cell(0,0)._tc...，且t是外部全局变量时，始终无效，调试几个小时未遂。后来又神奇地好了。
    cell._tc.get_or_add_tcPr().append(shading)


def useMyCmap(axes):
    #为了避免引入新包，可用mpl.rcsetup.cycler()来替代
    #from cycler import cycler
    cycler= mpl.rcsetup.cycler
    mycolors=['#40e0d0','#90ee90','#FFC6CD','#7dc9e7','#3bb0ba','#18a4e0','#fee698','#ff6f5e','#ba55d3','#f994c0','#e04255','#adff2f','#8dbf8b','#ffd700','#fdb15d','#daa520']
    mycycler=cycler('color', mycolors)
    axes.set_prop_cycle(mycycler)


def commaSep(loc, pos):
    if loc % 1.0:
        return f'{loc:,.2f}' #若有小数，则保留2位
    return f'{loc:,.0f}' #否则去掉0，加上1k逗号。

def autoRotateX(ax):
    # █ 取得渲染后的数据。在show()之前要得到数据必须在内存先画出来。
    r = ax.figure.canvas.get_renderer()
    ax.draw(r)
    ts=ax.get_xticklabels()
    #█所有标签加起来的宽度比x轴的宽度。按说应该是任意2个相邻标签交叠了就要整体旋转。
    w=0
    for t in ts:
        bb = t.get_window_extent(renderer=r)#.transformed(ax.transData.inverted()) #截掉的部分用来转换坐标数据
        width = bb.width
        height = bb.height
        w+=width
    xlen=ax.xaxis.get_tightbbox(r).width #x轴长度
    ra=w/xlen #文本总长度的占比
    ang=0 #左旋角度
    if ra<=0.8:
        ang=0
    elif ra>0.8 and ra<=1.0:
        ang=15
    elif ra>1.0 and ra<=1.2:
        ang=30
    elif ra>1.2 and ra<=1.4:
        ang=45
    elif ra>1.4 and ra<=1.6:
        ang=60
    elif ra>1.6 and ra<=1.8:
        ang=75
    else:
        ang=90
    # 重新布局
    ax.tick_params(axis='x',rotation=ang)
    align='right' if 0<ang<90 else 'center'
    for label in ax.get_xmajorticklabels() + ax.get_xmajorticklabels():
        label.set_horizontalalignment(align)


#为了解决末尾很多小细饼挤在一起，导致很多0.1%重叠和xx标签重叠，可以不给labels和autopct参数，只画一个光饼。然后自己加导引线和注释。如果注释中的标签很长，则占位过长；若标签和百分比分两行，则占位更高。
#但为了处理一致，可仍然按照普通的饼图传所有参数，但是把返回值给一个autoPieLabel的修正函数，里面隐藏默认得到的文本，自行加标注。
#█建议饼图划分最好不超过10份，且要降序后传入，使得小细饼都在最后集中优化处理。小细饼的文本最好从右侧偏底部一点开始，以便有足够空间向上堆积文本，而不至于与第一块饼的文本重叠。startangle=-30还是0°对小细饼文本最终位置的影响好像不是很大，故没有使用。
def autoPieLabel(pieinfo):
    #pieinfo要返回3项，labels和autopct参数都必须给。
    pies,labels,pcts=pieinfo
    #█arrowprops引导线的color优于edgecolor。都不写就是白色，跟底色一样，看不到。没有facecolor。整体的color针对文本颜色。
    #annotateKW中可加入bbox= dict(boxstyle="square,pad=0.3", fc="w", ec="k", lw=0.72)画边框。但fontsize = 18,weight = 'bold'要加入到annotate函数中才行。
    annotateKW = dict(arrowprops=dict(arrowstyle="-",color='b'), zorder=0, va="center",color='k')
    # 遍历饼块绘制注释标签和引导线
    #lastang是上个饼的真实中线角度，lastVang是为避免文本重叠需要抬高的虚拟角度。当lastVang不为空时，说明需要特殊处理。
    lastang,lastVang=None,None
    for i, p in enumerate(pies):
        pcts[i].set_visible(0)
        labels[i].set_visible(0)
        pct=pcts[i].get_text()
        label=labels[i].get_text()
        # 根据matplotlib.patches.Wedge对象的theta1和theta2参数计算饼块均分点的角度。非弧度。
        ang = (p.theta2 - p.theta1) / 2  + p.theta1
        # 根据角度的弧度计算 饼块均分点的坐标。██ 此为引导线的起点，也是annotate标注的目标点，箭头指向点。对应angleB。
        y = np.sin(np.deg2rad(ang))
        x = np.cos(np.deg2rad(ang))
        #print(p.theta1, p.theta2, ang, np.deg2rad(ang), x, y)
        #引导线起点加一个小点，颜色跟饼一样，因为不同类的图都是从颜色池循环。
        #plt.plot(x, y, "o")
        # █根据x所在象限，确定引导线对应标签文本锚向文本定位点的对齐方式。右边文本左对齐定位点，左边文本右对齐定位点。如果文本的对齐方式不对，则效果很怪。
        horizontalAlign = {-1: "right", 1: "left"}[int(np.sign(x))]
        #█文本对齐的定位点的位置，对应angleA。
        locx,locy=1.35 *np.sign(x), 1.4 * y
        #█过标注文本定位点，按angleA画直线，再过标注目标点，按angleB画直线，二者如何相交结果就是如何。如厂、7型的交点都在转折点。███ 如果connectionstyle参数不对，两条线在无穷远处相交，画不出来，就报错说图片太大。Image size of 1005x132589 pixels is too large. It must be less than 2^16 in each direction。以及 UserWarning: Tight layout not applied. The left and right margins cannot be made large enough to accommodate all axes decorations。在plt.text()和plt.annotate()中可能出现此错误。一说用ax.update_datalim()来扩展边界，一说给plt.text()加入 transform=ax.transAxes，或给plt.annotate()加入textcoords="offset points"。都不适用我的问题。
        #核心的修正过程
        phi=5
        if lastang: #非迭代首项
            if ang-lastang<phi: #若饼间过近
                if lastVang: #若之前已经抬高处理过
                    lastVang=lastVang+phi #继续抬高
                else: #自复位后又首次碰到需要抬高处理的情况
                    lastVang=ang+phi
            else: #已经脱离了过近的局面，不再做累积抬高处理，None表示脱离
                lastVang=None
        lastang=ang
        # 设置引导线的默认连接方式。angle3和arc3中的3意味着所得到的路径是二次样条段（三个控制点）。
        connectionstyle = f"angle,angleA=0,angleB={ang}"
        if lastVang: #做过抬高处理时，需要用新的算法更新文本定位点的垂直位置locy。且此时的连线不再根据饼中心，而是要根据文本位置计算抬高角。
            locy = 1.4 * np.sin(np.deg2rad(lastVang))
            newang=np.arctan((locy-y)/(locx-x)) #算新的连线弧度
            newang=np.rad2deg(newang)
            newang+=lastVang/2 #加此句的效果更好，不加也行
            connectionstyle = f"angle,angleA=0,angleB={newang}"
        annotateKW["arrowprops"].update({"connectionstyle": connectionstyle})
        #████ 下面这几个点就是标签对齐的文本定位点。必须写plt.gca().axis("equal")，才能看到下行这几个点的效果。但不写也不影响annotate。如果不要这几个点，可不写。plt.tight_layout()也可不写。
        #plt.plot(locx,locy, "or",markersize=5)
        plt.annotate(f'{pct} ┇ {label}', xy=(x, y), xytext=(locx,locy), ha=horizontalAlign, **annotateKW )

def autoLegend(ax,*otherAx):
    handles,labels=ax.get_legend_handles_labels()
    for i in otherAx:
        h, l = i.get_legend_handles_labels()
        handles+=h
        labels+=l
    n=len(handles)
    #1~5个图形，图例都用5个一列；6个图形为了对齐，图例用3个一列；其他见下。超出13个图形则都5个一列。
    m={1:5,2:5,3:5,4:5,5:5,6:3,7:4,8:4,9:5,10:5,11:4,12:4,13:5}
    ncol=m.get(n,5)
    fig=ax.figure
    r = fig.canvas.get_renderer()
    #h1是x轴刻度文本的高度，h2是x轴标签的高度，如果没有x轴标签，则h2=0。通常h3比h1+h2稍大一点。按说图例应该在-(h1+h2)的位置，或-h3的位置，但有时-h1的位置效果更紧凑。不过-h1的位置在某些时候还是会重叠，不行。所以最终选-(h1+h2)
    h1=ax.get_xticklabels()[0].get_tightbbox(r).transformed(fig.transFigure.inverted()).height
    h2=ax.xaxis.label.get_tightbbox(r).transformed(fig.transFigure.inverted()).height
    #h3=ax.xaxis.get_tightbbox(r).transformed(fig.transFigure.inverted()).height
    fig.legend(handles, labels,loc='center', bbox_to_anchor=(0.5,-(h1+h2)), ncol=ncol,frameon=1)

def pic2mem():
    flike = io.BytesIO()
    plt.savefig(flike,format='png', dpi=400, bbox_inches='tight')
    plt.close()
    return flike

#yspec是列表，一次性传入所有要画的东西的描述，为了给文档渲染引擎用，避免拼接一堆零散的绘图语句后再eval。如[('bar','主坐标',[数据序列],[图例标签],''),('bar','副坐标',[数据序列],[图例标签],'并排'),('line','主坐标',[数据序列],[图例标签]),('hline','主坐标',[数据序列]),('vline','副坐标',[数据序列]),('pie',图例序列)]，参数是自动生成的，合法性基本有保证，如并排/堆叠之外不会有非法取值。饼图时横轴数据xdata忽略，只看yspec。之所以用数据序列，而不是传入列名，是为了更通用，避免跟pd、np绑定。返回打开的内存文件flike，调用者负责 flike.close()！！

#superDraw每次都画新图，所以多次调用不会重叠。同一张图上，若先画bar再画pie，则最终留下的是pie，似乎默认就预先clear了前图。

def superDraw(xdata,yspec,xaxisLabel,yaxisLabel,yaxisLabel2):
    def correctTick4time():
        focus=plt.gca()
        plt.sca(ax) #必须是改主坐标刻度
        if xdataIsDay:
            labels=[time.strftime('%Y%m%d',i.timetuple()) for i in xdata]
        else:
            labels=[time.strftime('%Y%m%d_%H',i.timetuple()) for i in xdata]
        plt.xticks(range(xlen),labels)
        plt.sca(focus) #还原

    def pie(legendLabel):
        useMyCmap(ax)
        return ax.pie(xdata, labels=legendLabel,autopct=lambda v:format(v,'4.1f').replace(' ','  '), pctdistance=0.6, wedgeprops=dict(width=0.3,edgecolor='w',linewidth=2) )

    def bar(coor,ydata,legendLabel,how):
        axes=ax2 if coor=='副坐标' else ax
        useMyCmap(axes)
        plt.sca(axes)
        w=0.3
        n=len(ydata)
        mode='single' if n==1 else 'multi'
        if mode=='single':
            if xdataIsTime:
                plt.bar(x=xdata4time, height=ydata[0], width=w,label=legendLabel[0])
                correctTick4time()
            else:
                plt.bar(x=xdata, height=ydata[0], width=w,label=legendLabel[0])
        else:
            if how=='并排':
                #序列1的中心坐标，避免引入np包
                x0=[x-(n-1)*w/2 for x in range(xlen)]
                for i in range(n):
                    plt.bar(x=x0,height=ydata[i],width=w,label=legendLabel[i])
                    #更新下一个序列的中心坐标
                    x0=[i+w for i in x0]
                if xdataIsTime:
                    correctTick4time()
                else:
                    #并排模式下，无论输入是[时间]/[类别]/[数字]，都会映射为0~N，所以都需要纠正xtick
                    plt.xticks(xdata4time,xdata)
            else:
                accBottom=0
                if xdataIsTime:
                    for i in range(0,n):
                        plt.bar(x=xdata4time,height=ydata[i],bottom=accBottom,width=w,label=legendLabel[i])
                        #不断累积的底部，柱子3的底为柱子1高度+柱子2的高度。
                        accBottom+=ydata[i]
                    correctTick4time()
                else:
                    for i in range(0,n):
                        plt.bar(x=xdata,height=ydata[i],bottom=accBottom,width=w,label=legendLabel[i])
                        #不断累积的底部，柱子3的底为柱子1高度+柱子2的高度。
                        accBottom+=ydata[i]

    def line(coor,ydata,legendLabel):
        c=plt.rcParams["axes.prop_cycle"].by_key()['color']
        if coor=='副坐标':
            axes=ax2
            colors=c
            #副坐标画线时，如果已有主坐标的线，则副坐标颜色反过来取，回避。否则不用回避。
            if '主坐标' in [s[1] for s in yspec if s[0]=='line']:
                colors=list(reversed(c))
        else:
            axes=ax
            colors=c
        plt.sca(axes)
        #要考虑单独画线的场景，以及线跟别的图形共存的场景。
        #输入是[时间]，或者输入为[类别]/[数字]但在bar并排模式下，输入都会映射为0~N。
        if xdataIsTime:
            for i,d in enumerate(ydata):
                plt.plot(xdata4time,d,'.-',label=legendLabel[i],color=colors[i])
            correctTick4time()
        else:
            #感知外界，除了线条自己，是否还有别的图形。并排bar+[类别]输入时，plot([类别])本来就会将串映射到0~N，无需特殊处理；但并排bar+[数字]输入时，plot([数字])会导致坐标值域跟0~N差异太大，故只要有并排bar就都一起修正了，不区分输入。
            if hasParallelBar:
                for i,d in enumerate(ydata):
                    plt.plot(xdata4time,d,'.-',label=legendLabel[i],color=colors[i])
            else:
                for i,d in enumerate(ydata):
                    plt.plot(xdata,d,'.-',label=legendLabel[i],color=colors[i])

    def hline(coor,ydata):
        #如果主副坐标都有水平线，则起始颜色相同。场景很少，不搞了。
        axes=ax2 if coor=='副坐标' else ax
        plt.sca(axes)
        c=plt.cm.Accent
        for i,d in enumerate(ydata):
            plt.axhline(d,color=c(i),ls= '-.')

    def vline(coor,ydata):
        #axes=ax2 if coor=='副坐标' else ax
        axes=ax   #vline永远是在主坐标
        plt.sca(axes)
        c=plt.cm.Accent
        for i,d in enumerate(ydata):
            if isinstance(d,(Timestamp,datetime.datetime)):
                if not xdataIsTime:
                    oops('垂直线坐标为日期时，横轴数据必须都是日期类型')
                time0=mpl.dates.date2num(xdata[0])
                time1=mpl.dates.date2num(xdata.values[-1])
                time=mpl.dates.date2num(d)
                loc=(xlen-1)*(time-time0)/(time1-time0)
                #print(xdata[0],xdata.values[-1],d,time0,time1,time,xlen,loc)
                plt.axvline(loc,color=c(i),ls= '-.')
            else:
                plt.axvline(d,color=c(i),ls= '-.')

    #=======================
    plt.style.use('ggplot')
    plt.rcParams["figure.figsize"] = (9,5)
    plt.rc('font', family='Microsoft YaHei', size=10)
    plt.rcParams['axes.unicode_minus'] = False
    hasSlaveCoordinate=1 if [s[1] for s in yspec if type(s[1])==str and s[1]=='副坐标'] else 0
    barspec=[s for s in yspec if s[0]=='bar']
    barmode=[i[4] for i in barspec]
    hasParallelBar=1 if '并排' in barmode else 0
    fig, ax = plt.subplots(dpi=400)
    if hasSlaveCoordinate:
        ax2 = ax.twinx()
    xdataIsTime=False
    xdataIsDay=True
    if isinstance(xdata[0],(Timestamp,datetime.datetime)):
        xdataIsTime=True
        if any([i.timetuple().tm_hour for i in xdata]):
            xdataIsDay=False
    xlen=len(xdata)
    xdata4time=range(xlen)

    #如果x轴输入是时间，则接管自己处理。刻度浮点都映射到0~N。不再用以下自动的
    #if xdataIsTime:
    #    if xdataIsDay:
    #        ax.xaxis.set_major_locator(mpl.dates.DayLocator())
    #        ax.xaxis.set_major_formatter(mpl.dates.DateFormatter('%Y%m%d'))
    #    else:
    #        ax.xaxis.set_major_locator(mpl.dates.HourLocator())
    #        ax.xaxis.set_major_formatter(mpl.dates.DateFormatter('%Y%m%d_%H'))

    for s in yspec:
        t=s[0]
        if t=='pie':
            pieinfo=pie(*s[1:])
            autoPieLabel(pieinfo)
            #pie会提前返回，不做坐标、图例修正等工作。
            return pic2mem()
        elif t=='bar':
            bar(*s[1:])
        elif t=='line':
            line(*s[1:])
        elif t=='hline':
            hline(*s[1:])
        elif t=='vline':
            vline(*s[1:])
        else:
            print('未识别的图形类别')
            return

    autoRotateX(ax)
    ax.yaxis.set_major_formatter(commaSep)
    if xaxisLabel:
        ax.set_xlabel(xaxisLabel)
    if yaxisLabel:
        ax.set_ylabel(yaxisLabel)
    if hasSlaveCoordinate:
        ax2.yaxis.set_major_formatter(commaSep)
        ax2.grid(None)
        if yaxisLabel2:
            ax2.set_ylabel(yaxisLabel2)
        autoLegend(ax,ax2)
    else:
        autoLegend(ax)
    return pic2mem()
    #plt.show()




if __name__=='__main__':
    #from docrender import render
    import os
    import pandas as pd
    import random
    import datetime
    import numpy as np

    def daysago(from_,n):
        res = []
        from_=datetime.datetime.strptime(from_,f'%Y%m%d')
        for i in range(n)[::-1]:
            res.append(from_ - datetime.timedelta(days=i))
        return res

    def level(sale):
        return '优秀' if sale > 800 else '合格'

    quarter=2
    city='上海'
    sale=888

    N=14
    sales=pd.DataFrame({
        'day1': pd.date_range(datetime.datetime(2021,8,8), periods=N),
        'day2': pd.date_range(datetime.datetime(2021,8,8), periods=N,freq='1h'),
        'day3': daysago('20210808',N),
        '类别':[f'c{i}' for i in range(N)],
        '华东':[random.uniform(120, 180) for i in range(N)],
        '华南':[random.uniform(90, 120) for i in range(N)],
        '华中':[random.uniform(80, 100) for i in range(N)],
        'total':[random.uniform(5, 150) for i in range(N)],
        'price':[random.uniform(10, 100) for i in range(N)],
        'ratio':[random.uniform(10, 100) for i in range(N)],
        'target':[random.uniform(10, 100) for i in range(N)],
        'seq':[i for i in range(2000,2000+N*100,100)],
        'huge':[random.uniform(100000, 300000) for i in range(N)],
        })

    sales子表=sales[['day1','price','ratio','total','target','seq','huge']]

    testpie=pd.DataFrame({
        'brand': ["小米", "三星", "华为", "苹果", "魅族", "VIVO", "OPPO"],
        'sale':[random.uniform(10, 200) for i in range(7)]
        })

    testpie2=pd.DataFrame({
    'labels':['HUAWEI Mate 30 5G', 'HUAWEI Mate 30 Pro 5G', 'HUAWEI P40 5G', 'HUAWEI P40 Pro 5G', '中兴 天机Axon10s Pro 5G', '荣耀 V30 Pro 5G', 'HUAWEI Nova7 5G', 'HUAWEI Mate20X 5G'],
    'pcts1':[ 36 , 138 ,193 ,2 , 8 , 2 ,  1 , 0 ],
    'pcts2':[ 436 , 138 ,193 ,2 , 8 , 2 ,  1 , 0 ]
    })

    render(r'D:\prog\comp.lang.python\docx模板\myrender\test.docx')
    os.startfile(r'D:\prog\comp.lang.python\docx模板\myrender\test_输出.docx')


    #图片不落盘；x文本自适应旋转；建多坐标轴；饼图颜色足够；图例在下；值标注加逗号；纵横线；





